"use strict";

const $ = function(selector, context){
	return (context && context.querySelector ? context : document).querySelector(selector);
};
$.html = function($el, html){
	let $dom = $el.cloneNode();
		$dom.innerHTML = html;
		$dom.childNodes.forEach(c => $el.appendChild(c));
	return $el.childNodes[$el.childNodes.length - 1];
}
$.innerText = function($el, text){
	let attr = 'textContent' in $el ? 'textContent' : 'innerText';
	if( text === undefined || text === null ){
		return $el[attr] || '';
	}
	else {
		return $el[attr] = text;
	}
}
$.innerHTML = function($el, html){
	if( html === undefined || html === null ){
		return $el.innerHTML || '';
	}
	else {
		return $el.innerHTML = html;
	}
}
const Clock = function(interval=0){
	let clock = new.target ? this : {};
	let lastime = performance.now();
	let exptime = lastime;
	clock.interval = interval;
	clock.getDelta = () => {
		let curtime = performance.now();
		let pastime = curtime - lastime;
		lastime = curtime;
		return pastime;
	}
	clock.explore = (extra = 0, sync = true) => {
		let curtime = performance.now();
		if( curtime - exptime >= interval + extra ){
			if( sync ){
				exptime = curtime;
			}
			return true;
		}
		return false;
	}
	clock.refresh = ()=>{
		lastime = exptime = performance.now();
		return clock;
	}
	return clock;
}
class Event {
	constructor(object){
		this.list = {};
		this.object = object;
	}
	add(name, func, opts){
		let key = name.split(':')[0];
		let list = this.list[key] || (this.list[key] = []);
			list.push(Object.assign(opts || {}, {name, func}));
		return list.length;
	}
	once(name, func){
		return this.add(name, func, {once:true});
	}
	remove(name, func){
		let key = name.split(':')[0];
		let list = this.list[key];
		let items = list.filter(event => event.name == name && (func ? event.func == func : true));
			items.forEach(event => {
				list.splice(list.indexOf(event), 1);
			});
		return items.length;
	}
	clear(name){
		if( arguments.length > 1 ){
			return [...arguments].reduce((ret, name) => ret += this.clear(name), 0);
		}
		let list = this.list[name];
		if( list ){
			return list.splice(0, list.length).length;
		}
		return 0;
	}
	dispatch(name){
		let key = name.split(':')[0];
		let evn = name.indexOf(':') == -1 ? null : name;
		let list = this.list[key];
		if( list ){
			let items = list.filter(event => !evn || event.name == evn);
				items.forEach(event => {
					event.func(this.object);
					if( event.once ){
						list.splice(list.indexOf(event), 1);
					}
				});
			return items.length;
		}
		return 0;
	}
	fire(name){
		return this.dispatch(name);
	}
}
class Segment {
	static dom2class = {};
	static generate = function({str, dom, type, html, content, speed = 0, cstart = null, parent = null}){
		let clazz = Segment.dom2class[dom];
		if( clazz ){
			return new clazz(...arguments);
		}
		return new Segment(...arguments);
	}
	static parseHTML = (str)=>{
		if( str.charAt(0) != '<' ){
			return null;
		}
		let matcher = str.match(/^<([\w:]+)(\s+[\w-]+(=(['"]).+?\4)?)*?(\s*\/?\s*>)/mi);
		if( matcher ){
			let fmat = matcher[0];
			let dom = matcher[1];
			let suffix = matcher[5].replace(/\s/g, '');
			if( suffix == '/>' ){
				return {
					dom,
					str : fmat,
					html : fmat,
					content : '',
				};
			}
			let kin = 0;
			let i = fmat.length;
			while( i<str.length ){
				let char = str.charAt(i);
				if( char == '\\' ){
					char = str.charAt(++i);
				}
				else if( char == '<' ){
					let nstr = str.substr(i);
					matcher = nstr.match(new RegExp('^<\\s*\\/\\s*' + dom + '>', 'mi'));
					if( matcher ){
						kin--;
						if( kin < 0 ){
							return {
								dom,
								str : str.substring(0, i + matcher[0].length),
								html : fmat + matcher[0],
								content : str.substring(fmat.length, i),
							};
						}
					}
					else {
						matcher = nstr.match(new RegExp(`^<${dom}(\\s+[\\w-]+(=(['"]).+?\\3)?)*?\\s*>`, 'mi'));
						if( matcher ){
							kin++;
						}
					}
				}
				i++;
			}
		}
		return null;
	}
	static sbuild = function(generate){
		return {
			txt : '',
			data : [],
			str(c){
				this.txt += c;
			},
			push(segment){
				this.data.push(segment);
			},
			conclusion(){
				if( this.txt ){
					let optxts = {
						dom : 'span',
						prefix : '<span class="cactxt">',
						suffix : '</span>'
					};
					this.data.push(generate({
						type : 'txt',
						dom : optxts.dom,
						html : optxts.prefix + optxts.suffix,
						str : optxts.prefix + this.txt + optxts.suffix,
						content : this.txt,
					}));
					this.txt = '';
				}
			},
			isPureText(){
				return this.data.length == 1 && this.data[0].type == 'txt';
			}
		};
	}
	constructor({str, dom, type, html, content, speed = 0, cstart = null, parent = null, requestAnimationFrame=window.requestAnimationFrame.bind(window)}){
		this.str = str;
		this.dom = dom;
		this.type = type;
		this.html = html;
		this.$dom = null;
		this.speed = speed;
		this.cstart = cstart;
		this.parent = parent;
		this.content = content;
		this.requestAnimationFrame = requestAnimationFrame;
		this.data = [];
		this.event = new Event(this);
		if( type != 'txt' ){
			this.parse();
		}
		this.event.dispatch('parsed');
	}
	root(){
		let that = this;
		while(that.parent){
			that = that.parent;
		}
		return that;
	}
	parse(){
		let generate = (segment) => {
			return Segment.generate(Object.assign(segment, {
				speed:this.speed, 
				cstart:this.cstart, 
				parent:this, 
				requestAnimationFrame:this.requestAnimationFrame
			}));
		}
		let sbuilder = new Segment.sbuild(generate);
		let parseHTML = Segment.parseHTML;
		let i = 0, str = this.content;
		while(i < str.length ){
			let char = str.charAt(i);
			if( char == '\\' ){
				char = str.charAt(++i);
			}
			else if( char == '<' ){
				let mat = parseHTML(str.substr(i));
				if( mat ){
					sbuilder.conclusion();
					sbuilder.push(generate(mat));
					i += mat.str.length;
					continue;
				}
			}
			sbuilder.str(char);
			i++;
			if( char == '\n' && this.cstart ){
				let nstr = str.substr(i);
				if( new RegExp('^\\s*\\' + this.cstart).test(nstr) ){
					i += nstr.indexOf(this.cstart) + 1;
				}
			}
		}
		sbuilder.conclusion();
		if( sbuilder.isPureText() ){
			let segment = sbuilder.data[0];
			this.content = segment.content;
			if( !this.dom ){
				this.dom = segment.dom;
				this.html = segment.html;
				this.str = segment.str;
			}
		}
		else {
			sbuilder.data.forEach(s => {
				this.data.push(s);
			});
		}
	}
	out($el, clock, resolve, i=-1){
		const out = ($el, clock, resolve, i=-1) => {
			if( this.html ){
				if( !this.$dom ){
					this.$dom = $.html($el, this.html);
				}
				$el = this.$dom;
			}
			if( !this.data.length ){
				this.print($el, this.content, 0, clock, resolve);
			}
			else {
				if( ++i >= this.data.length ){
					return resolve(this);
				}
				this.data[i].out($el, clock, ()=>{
					out($el, clock, resolve, i);
				});
			}
		}
		out($el, clock, resolve, i);
	}
	print($el, str, i, clock, resolve){
		if( this.speed < 0 ){
			$.innerText($el, str);
			return resolve(this);
		}
		const print = ($el, str, i, clock, resolve) => {
			if( i >= str.length ){
				return resolve(this);
			}
			if( this.explore(clock) ){
				let char = str.charAt(i);
				if( char == '\\' ){
					char = str.charAt(++i);
				}
				$.innerText($el, $.innerText($el) + char);
				i++;
			}
			this.requestAnimationFrame(()=> print($el, str, i, clock, resolve));
		}
		print($el, str, i, clock, resolve);
	}
	explore(clock, interval=0){
		return clock.explore(interval || (this.speed ? this.speed - clock.interval : 0));
	}
}
class SegmentConsolact extends Segment {
	constructor(...args){
		super(...args);
	}
	parseBefore(){

		let root = this.root();
		if( root.spare === undefined ){
			root.spare = {};
			root.getSpare = (name) => root.spare[name];
			root.addSpare = (spare) => root.spare[spare.name] = spare;
		}

		let matcher = this.html.match(/delay\s*=\s*(['"])(-?\d+)\1/i);
		if( matcher ){
			this.delay = parseInt(matcher[2]);
		}
		matcher = this.html.match(/speed\s*=\s*(['"])(-?\d+)\1/i);
		if( matcher ){
			this.speed = parseInt(matcher[2]);
		}
		matcher = this.html.match(/cstart\s*=\s*(['"])(.+?)\1/i);
		if( matcher ){
			this.cstart = matcher[2];
		}
		matcher = this.html.match(/crlf\s*=\s*(['"])(1|0|true|false)\1/i);
		if( matcher ){
			this.crlf = ['0','false'].indexOf(matcher[2].toLowerCase()) != -1;
		}
	}
	parseAfter(){
		if( this.crlf ){
			let re = /\n\s*$/m;
			this.parent.event.once('parsed', ()=>{
				for(let i=0; i<this.parent.data.length; i++){
					let segment = this.parent.data[i];
					if( segment == this ){
						let sibling = this.parent.data[i-1];
						if( sibling ){
							sibling.content = sibling.content.replace(re, '');
						}
						break;
					}
				}
			});
			if( this.data.length ){
				let segment = this.data[this.data.length - 1];
					segment.content = segment.content.replace(re, '');
			}
		}
	}
	parse(...args){
		this.parseBefore();
		super.parse(...args);
		this.parseAfter();
	}
	out($el, clock, resolve, i=-1){
		if( this.delay ){
			const func = () => {
				if( this.explore(clock, this.delay - clock.interval) ){
					super.out(...arguments);
				}
				else {
					this.requestAnimationFrame(func);
				}
			}
			func();
			//setTimeout(() => super.out(...args), this.delay);
		}
		else {
			super.out(...arguments);
		}
	}
}
Segment.dom2class['a:box'] = SegmentConsolact;
Segment.dom2class['a:wrap'] = SegmentConsolact;


class SegmentUndo extends SegmentConsolact {
	constructor(...args){ super(...args); }
	out($el, clock, resolve, i=-1){
		const undo = {};
			undo.segment = (segment, resolve, i=null) => {
				if( !(segment instanceof Segment) ){
					throw new Error('Segment only.' + segment);
				}
				if( !segment.data.length ){
					if( segment instanceof SegmentUndo ){
						if( segment.undone ){
							return resolve(this);
						}
					}
					let str = $.innerText(segment.$dom);
					return undo.text(segment.$dom, str, str.length || 0, clock, resolve);
				}
				if( i == null ){
					i = segment.data.length; 
				}
				if( --i < 0 ){
					return resolve(this);
				}
				undo.segment(segment.data[i], ()=>{
					undo.segment(segment, resolve, i);
				});
			}
			undo.text = ($el, str, i, clock, resolve) => {
				if( i <= 0 || !str ){
					return resolve(this);
				}
				if( this.explore(clock) ){
					$.innerText($el, str.substr(0, --i));
				}
				this.requestAnimationFrame(()=> undo.text($el, str, i, clock, resolve));
			}
			undo.resolve = () => {
				undo.segment(this, () => {
					this.undone = true;
					resolve(this);
				});
			}
		super.out($el, clock, undo.resolve, i);
	}
}
Segment.dom2class['a:undo'] = SegmentUndo;


class SegmentSpare extends SegmentConsolact {
	constructor(...args){ super(...args); }
	parseBefore(...args){
		super.parseBefore(...args);
		if( this.crlf === undefined ){
			this.crlf = true;
		}
		let matcher = this.html.match(/name\s*=\s*(['"])([\w\d-]+?)\1/i);
		if( matcher ){
			this.name = matcher[2];
			this.root().addSpare(this);
		}
	}
	parse(){
		this.parseBefore();
		this.parseAfter();
	}
	out($el, clock, resolve, i=-1){
		this.$dom = document.createElement('i');
		if( this.delay ){
			const func = () => {
				if( this.explore(clock, this.delay - clock.interval) ){
					resolve(this);
				}
				else {
					this.requestAnimationFrame(func);
				}
			}
			func();
		}
		else {
			resolve(this);
		}
	}
}
Segment.dom2class['a:spare'] = SegmentSpare;


class SegmentShift extends SegmentConsolact {
	static Shift = function({that, clock, resolve}){
		let shift = new.target ? this : {};
			shift.explore = (interval) => {
				return that.explore(clock, (interval || clock.interval) - clock.interval)
			}
			shift.opts = (segment, opts = {/*inherit*/}) => {
				let nval = (...args) => args.find(s => s !== null && s !== undefined);
				return Object.assign({}, opts, segment.opts, {
					relapse : segment.opts.relapse || opts.relapse || 0,
					relapsed : nval(segment.opts.relapsed, opts.relapsed),
					exenum : 0,
					exec(resolve){
						if( this.isDone() ){
							segment.shifted = true;
							return resolve(segment);
						}
						shift.segment(segment, ()=>{
							this.exenum++;
							this.exec(resolve);
						}, this);
					},
					isDone(){
						return this.exenum > this.relapse;
					},
					getFrom(){
						return ((!this.relapse || this.exenum % 2) ? this.from : this.to) || '';
					},
					getTo(){
						let func = () => {
							if( !this.relapse ) {
								return this.to;
							}
							if( this.exenum == this.relapse ){
								if( nval(this.relapsed) ){
									return this.relapsed;
								}
							}
							if( this.exenum % 2 ){
								return this.to;
							}
							return this.from;
						}
						return func() || '';
					}
				});
			}
			shift.segment = (segment, resolve, opts, i=-1) => {
				if( !(segment instanceof Segment) ){
					throw new Error('Segment only.' + segment);
				}
				if( segment instanceof SegmentShift ){
					if( segment.shifted ){
						return resolve(that);
					}
				}
				if( !segment.data.length ){
					return shift.text(opts, segment.$dom, $.innerText(segment.$dom), resolve);
				}
				if( ++i >= segment.data.length ){
					return resolve(that);
				}
				let fnext = () => shift.segment(segment, resolve, opts, i);
				let csegment = segment.data[i];
				if( csegment instanceof SegmentShift ){
					shift.opts(csegment, opts).exec(fnext);
				}
				else {
					shift.segment(csegment, fnext, opts);
				}
				//shift.segment(segment.data[i], ()=> shift.segment(segment, resolve, opts, i), optx);
			}
			shift.text = (opts, $el, str, resolve, cache = {}) => {
				if( !str ){
					return resolve(that);
				}
				let search = opts.getFrom();
				if( !search ){
					return resolve(that);
				}
				if( !cache.search ){
					if( !cache.index ){
						cache.index = 0;
					}
					if( !cache.re ){
						if( search.indexOf('re:') == 0 ){
							cache.re = new RegExp(search.substr(3));
						}
					}
					if( cache.re ){
						let matcher = str.substr(cache.index).match(cache.re);
						if( !matcher ){
							return resolve(that);
						}
						search = matcher[0];
					}
					else {
						if( str.substr(cache.index).indexOf(search) == -1 ){
							return resolve(that);
						}
					}
					cache.search = search;
				}
				if( !cache.replace ){
					let replace = opts.getTo();
					if( replace != cache.to ){
						cache.to = replace;
						let matcher = replace.match(/^(.*?)\@\{([\w\d-]+?)\}(.*)$/);
						if( matcher ){
							let spare = that.root().getSpare(matcher[2]);
							if( spare ){
								replace = matcher[1] + spare.content + matcher[3];
							}
						}
					}
					if( cache.search == replace ) {
						return resolve(that);
					}
					cache.replace = replace;
				}
				if( shift.explore(opts.interval) ){
					if( !opts.gradual ) {
						$.innerHTML($el, str.split(cache.search).join(cache.replace));
						return resolve(that);
					}
					str = str.substring(0, cache.index) + str.substr(cache.index).replace(cache.search, (search, index, str) => {
						cache.index = index + cache.replace.length;
						return cache.replace;
					});
					$.innerHTML($el, str);
					cache.search = null;
					cache.replace = null;
				}
				that.requestAnimationFrame(()=> shift.text(opts, $el, str, resolve, cache));
			}
			shift.resolve = ()=> {
				let hasShiftParent = false && ((segment) => {
					while(segment){
						if( segment.parent instanceof SegmentShift ){
							return true;
						}
						segment = segment.parent;
					}
					return false;
				})(that);

				if( hasShiftParent ){
					resolve(that);
				}
				else {
					shift.opts(that).exec(resolve);
				}
			}
		return shift;
	};
	constructor(...args){ super(...args); }
	parseBefore(){
		this.opts = this.opts || {};
		let matcher = this.html.match(/from\s*=\s*(['"])(.+?)\1/i);
		if( matcher ){
			this.opts.from = matcher[2];
		}
		matcher = this.html.match(/to\s*=\s*(['"])(.*?)\1/i);
		if( matcher ){
			this.opts.to = matcher[2];
		}
		matcher = this.html.match(/interval\s*=\s*(['"])(-?\d+)\1/i);
		if( matcher ){
			this.opts.interval = parseInt(matcher[2]);
		}
		matcher = this.html.match(/relapse\s*=\s*(['"])(\d+)\1/i);
		if( matcher ){
			this.opts.relapse = parseInt(matcher[2]);
		}
		matcher = this.html.match(/relapsed\s*=\s*(['"])(.*?)\1/i);
		if( matcher ){
			this.opts.relapsed = matcher[2];
		}
		matcher = this.html.match(/gradual\s*=\s*(['"])(1|0|true|false)\1/i);
		if( matcher ){
			this.opts.gradual = ['1','true'].indexOf(matcher[2].toLowerCase()) != -1;
		}
		super.parseBefore();
	}
	out($el, clock, resolve, i=-1){
		super.out($el, clock, new SegmentShift.Shift({that:this, clock, resolve}).resolve, i);
	}
}
Segment.dom2class['a:shift'] = SegmentShift;


class Consolact {
	constructor({content, cstart, interval=150}){
		this.segment = new Segment({content,cstart, requestAnimationFrame : (callback) => this.requestAnimationFrame(callback)});
		this.clock = new Clock(interval);
		this.interval = interval;
		this.pausing = false;
		this.rafuncs = [];
		this.event = new Event(this);
	}
	pause(){
		this.pausing = true;
		this.event.dispatch('pause');
	}
	resume(){
		this.pausing = false;
		this.clock.refresh();
		let func = this.rafuncs.shift();
		while( func ){
			this.requestAnimationFrame(func);
			func = this.rafuncs.shift();
		}
		this.event.dispatch('resume');
	}
	requestAnimationFrame(callback){
		if( this.pausing ){
			this.rafuncs.push(callback);
		}
		else {
			window.requestAnimationFrame(callback);
		}
	}
	print(opts){
		let {$el, resolve, isWrap, cursor} = Object.assign({
			resolve : () => console.info('done.' + this.clock.getDelta()),
			isWrap : true,
			cursor : '_',
			$el : document.createElement('div'),
		}, opts || {});

		if( isWrap ){
			$el = $.html($el, '<pre class="consolact typing"></pre>');

			let $style = document.querySelector('style.consolact');
			if( !$style ){
				$style = $.html($el, '<style class="consolact" type="text/css"></style>');
				$style.innerHTML = `
					.consolact{word-break:break-all;overflow-wrap:break-word;white-space:pre-wrap;font-family:Consolas, Monaco, Courier, monospace;background-color:#121314;color:#ccc;width:100%;height:100%;}
					.consolact.typing::after{content:"${cursor}";animation:cursor 1s infinite;}
					@keyframes cursor{
						50%{opacity:0; }
						100%{opacity:1; }
					}
					a\:undo, a\:spare, a\:shift{display: contents;}
				`.replace(/^\s+/mg, '');
				(document.head || document.querySelector('html > head') || document.body).appendChild($style);
			}
			this.event.add('pause', () => {
				$el.className = ($el.className || '').replace(/typing/g, '');
			});
			this.event.add('resume', () => {
				$el.className = ($el.className || '') + ' typing';
			});
			this.event.add('done', () => {
				$el.className = ($el.className || '').replace(/typing/g, '');
			});
		}
		this.segment.out($el, this.clock.refresh(), ()=>{
			if( typeof(resolve) === 'function' ){
				resolve($el);
			}
			this.event.dispatch('done');
			this.event.clear('resume');
			this.event.clear('pause');
			this.event.clear('done');
		});
		return this;
	}
}


(function(){
	if (typeof module === 'object' && typeof module.exports === 'object') {
		module.exports = Consolact;
	}
	else {
		if (typeof window === 'object') {
			window.Consolact = Consolact;
		}
	}
})();


export {
	Clock,
	Event,
	Segment,
	SegmentConsolact,
	SegmentUndo,
	SegmentSpare,
	SegmentShift,
	Consolact,
};
